Skip to content

runtime: block signal_recv on targets without signal delivery (#5619) - #5620

Merged
deadprogram merged 1 commit into
tinygo-org:devfrom
neomantra:nm-signal-stub
Sep 19, 2026
Merged

deadprogram merged 1 commit into
tinygo-org:devfrom
neomantra:nm-signal-stub

Conversation

@neomantra

Copy link
Copy Markdown
Contributor

This was worked through with LLM. The text below is LLM-generated and I have read and reviewed all the code.

But I did finally get the BubbleTea List demo running in browser, compiled by TinyGo!

image

Summary

Make the stubbed os/signal.signal_recv block forever instead of returning
immediately, so signal.Notify no longer starves cooperative schedulers on
wasm and baremetal targets.

Fixes #5619

Problem

src/runtime/signalstub.go (build tags tinygo.wasm || baremetal) stubbed
signal_recv as return ^uint32(0). Upstream's os/signal.loop calls
signal_recv in a tight loop with no yield point — it relies on the
runtime blocking until a signal arrives, as the POSIX implementation in
runtime_unix.go does. With the immediate-return stub, the watcher
goroutine started by signal.Notify spins forever.

On a cooperative scheduler a spinning goroutine is always runnable, so the
scheduler never idles: on wasm, _start never returns to the host event
loop and the browser tab (or node/wazero) pins a core with all other
goroutines starved. Any program calling signal.Notify is affected —
Bubble Tea does so unconditionally, which is how this surfaced (a V8
profile of the hung program showed 99.7% of ticks in os/signal.loop).

Fix

signal_recv now calls deadlock() — the same primitive a blocking empty
select uses — parking the watcher goroutine forever. That matches the
real implementation's observable behavior on a system where no signal ever
arrives: Notify succeeds, the channel simply never receives anything, and
everything else keeps running. This is also what gc's js/wasm port does.

Verification

  • New behavioral test testdata/signalnotify.go (signal.Notify, then
    time.Sleep, then print done), registered for all platforms. The
    existing signal.go test is skipped on wasm/baremetal/windows, which is
    why this had no coverage.
  • Red: on stock dev @ 86d58db the wasm test hangs until the Go test
    timeout kills it.
  • Green with this change: TestBuild/WebAssembly/signalnotify.go and
    TestBuild/Host/signalnotify.go both pass (the host run exercises the
    real POSIX signal_recv path, guarding both implementations).
  • End to end: a go-booba-adapted Bubble Tea bubbles/list app compiled for
    GOOS=js GOARCH=wasm previously froze the browser tab at 100% CPU right
    after startup; with this change it runs interactively.
  • deadlock() is defined by all four scheduler implementations
    (cooperative, threads, cores, none), covering the stub's whole build-tag
    surface.
  • Not run locally: cortex-m-qemu / AVR / RISC-V builds of the new test
    (this environment lacks the LLVM source checkout for compiler-rt); CI
    covers those. If os/signal turns out not to fit AVR flash, the test can
    be excluded for AVR the way json.go/stdlib.go are.

Context

Found while verifying the large-parameter-spilling work (#5615) in a real
browser; it is independent of that change and reproduces on stock dev.

@0pcom

0pcom commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Independently verified this fix, from outside the CI harness: applied just the signalstub.go change to a stock 0.41.1 TINYGOROOT and ran the equivalent of testdata/signalnotify.go (signal.Notify + time.Sleep + println) under node v26 with GOOS=js GOARCH=wasm.

The deadlock() shape also seems right rather than just expedient: it parks the os/signal.loop goroutine the same way the real implementation blocks while no signal is pending, the cooperative scheduler carries on, and since signalstub.go is also the baremetal path it fixes the same starvation there. Works against the 0.41.1 runtime sources unchanged, so it should backport trivially if a point release happens before 0.43.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new test will fail to link on Windows without an exclusion or Windows signal stubs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Prevents signal.Notify from starving cooperative schedulers on targets without signal delivery.

Changes:

  • Blocks stubbed signal reception indefinitely.
  • Adds and registers a scheduler-progress regression test.
  • Defines the expected test output.
File summaries
File Description
testdata/signalnotify.txt Defines expected output.
testdata/signalnotify.go Tests progress after signal.Notify.
src/runtime/signalstub.go Parks unsupported signal reception.
main_test.go Registers the test, but does not exclude unsupported Windows hosts.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread main_test.go
"print.go",
"reflect.go",
"signal.go",
"signalnotify.go",
@deadprogram

Copy link
Copy Markdown
Member

I have not really looked into this PR, but it for sure needs a couple of things:

  • rebase against the latest dev
  • update it based on the AGENTS.md file guidelines

Thanks!

@neomantra

Copy link
Copy Markdown
Contributor Author

I've rebased, simplified the comments, and addressed the Copilot Windows skipping suggestion.

@deadprogram

Copy link
Copy Markdown
Member

Thanks @neomantra for the rebase and the updates. Almost there, just one little thing. See below, edited from an automated review:

  1. The new Windows skip in main_test.go has no reason comment, while the other skips in runPlatTests all explain themselves. Please add a short reason, and consider folding it into the signal.go skip just above it. For example:
if options.GOOS == "windows" {
	switch name {
	case "signal.go", "signalnotify.go":
		// os/signal does not link on Windows.
		continue
	}
}
if isWebAssembly || isBaremetal {
	switch name {
	case "signal.go":
		// Signals only work on POSIX-like systems.
		continue
	}
}

The signal_recv stub returns immediately. After signal.Notify starts
the signal goroutine, os/signal.loop calls the stub continuously.
On cooperative schedulers, this prevents other goroutines from
running. On js/wasm, it also prevents control from returning to
the host.

Call deadlock() to block the signal goroutine permanently. These
targets cannot deliver signals.

Add a regression test that calls signal.Notify and then sleeps.
The test checks that the main goroutine can resume and exit.
Skip this test on Windows, which has no signal implementation.

Fixes tinygo-org#5619

Signed-off-by: Evan Wies <evan@neomantra.net>
@neomantra

Copy link
Copy Markdown
Contributor Author

I've rebased this and addressed that comment, thanks!

@deadprogram deadprogram left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this @neomantra

@deadprogram
deadprogram merged commit d4ebca0 into tinygo-org:dev Sep 19, 2026
33 checks passed
@neomantra
neomantra deleted the nm-signal-stub branch September 28, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os/signal: signal.Notify permanently starves the scheduler on wasm (stubbed signal_recv returns immediately)

4 participants